iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
ChatGPT & Codex

AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發系列 第 10 篇

Day 10|這個規則到底該寫在哪?UI、Stored Procedure 還是 Trigger?

  • 分享至 

  • xImage
  •  

同一條商業規則寫三次,不叫三層防護;如果三份邏輯會各自演化,那只是準備製造三種答案。


前言/情境導入

上一篇,我處理了兩位使用者同時新增時,可能拿到相同流水號的 Race Condition。

最後採取的方向,是把「取得下一號」與「新增主檔」放進同一個 Stored Procedure,再透過 Transaction、Lock 與 Unique Index,避免兩個 Session 同時使用相同號碼。

併發問題解決後,另一個問題很快浮上來:

流水號的格式檢查、順序判斷與新增限制,到底應該寫在哪一層?

Legacy System 裡,答案經常不是「某一層」,而是「每一層都有一點」。

Delphi 的新增按鈕先算一次,Stored Procedure 又算一次,Trigger 再檢查一次。表面上像三層都在保護資料,實際上卻可能產生三個答案:

  1. Delphi 認為下一號是 0296,Stored Procedure 算出 0648;
  2. Stored Procedure 已允許四碼新制,Trigger 還停在三碼舊制;
  3. 批次程式繞過 Delphi 直接寫表,走了完全不同的規則。

這時真正的問題不再是「哪一段 SQL 寫錯」,而是:

誰才是這條規則的唯一決策者?其他層又應該負責到什麼程度?

今天就來拆開 UI、Stored Procedure、Trigger、Constraint 與 Unique Index 的責任,避免同一條規則在系統裡長出幾個互相不認識的分身。


核心技術解析

看起來都是檢查,其實不是同一種責任

討論規則放在哪裡之前,我先把需求分成四類:

規則類型 例子 主要目的
畫面互動規則 必填欄位未填時立即提示 改善操作體驗
Use Case 規則 儲存時取得正式流水號並新增主檔 完成完整業務動作
資料不變量 同一案件內流水號不可重複 任何入口都不能破壞
額外防線 非標準入口違反已明確定義的規則時仍會被拒絕 防止繞過主要流程

如果沒有先分類,很容易把所有規則都叫做 Validation,接著每一層都複製一份。

但「讓使用者早一點看到提示」與「資料庫絕對不能接受重複號碼」,其實是兩件完全不同的事。

UI 的檢查可以被其他 Client、匯入工具、排程或人工 SQL 繞過;Schema 層的 Constraint 與 Unique Index 雖然可靠,卻不適合負責顯示「請先選擇案件」這類互動訊息。

所以不應先問:

這段 IF 要放 Delphi 還是 SQL?

而應先問:

這條規則是在改善操作、執行流程,還是保護任何情況下都不能被破壞的資料狀態?


UI:可以先提醒,但不能成為最後裁判

UI 最適合處理與目前畫面直接相關,而且能立即回饋使用者的檢查,例如:

  • 案件編號尚未選擇;
  • 標題未輸入;
  • 日期格式不正確;
  • 使用者尚未確認警告訊息;
  • 儲存進行中,暫時停用按鈕避免重複點擊。

匿名化後的 Delphi 程式可以寫成:

procedure TDocumentForm.ValidateInput;
begin
  if Trim(edtProjectNo.Text) = '' then
    raise Exception.Create('請先選擇案件。');

  if Trim(edtTitle.Text) = '' then
    raise Exception.Create('請輸入文件標題。');
end;

這類檢查放在 UI,可以讓使用者不必等資料庫回傳錯誤,也能直接對應畫面欄位。

但 UI 不適合決定正式流水號,更不能單獨保證唯一性。因為 UI 送出後,資料庫狀態仍可能被其他 Session 改變;即使 Delphi 查到 0295,下一個瞬間也可能有人新增 0296。

而且 UI 從來不是唯一入口。系統未來可能還有另一支 Delphi 程式、Web API、Excel 匯入工具、排程作業或資料修復腳本。

只寫在 UI 的商業規則,保護的是「這個畫面」,不是「這份資料」。

UI Validation 是提早回饋,不是資料完整性的最終保證。


Stored Procedure:負責完成一個完整業務動作

上一篇已經確認,正式流水號不應由 Delphi 自己執行 MAX + 1。

Stored Procedure 適合承擔的,是一個明確的 Use Case:

接收新增資料
    ↓
驗證建立文件所需條件
    ↓
依案件取得並鎖定下一號
    ↓
新增主檔
    ↓
提交交易
    ↓
回傳已成功建立的正式號碼

它掌握完整的資料庫交易,也能看見真正已提交的資料,因此適合作為產號流程的唯一決策點。

下面只保留能說明責任歸屬的關鍵片段。UPDLOCK、HOLDLOCK、完整例外處理與併發測試已在 Day 9 說明,這裡不再重新展開:

-- 以下為責任分工示意,不是完整可部署版本。
-- 正式版本外層仍保留 Day 9 的 TRY...CATCH、XACT_ABORT 與 Rollback。
CREATE OR ALTER PROCEDURE dbo.CreateDocument
    @ProjectNo varchar(20),
    @Title     nvarchar(100)
AS
BEGIN
    DECLARE @NextValue int;
    DECLARE @NextNo varchar(4);

    IF NULLIF(LTRIM(RTRIM(@ProjectNo)), '') IS NULL
        THROW 50001, N'案件編號不可空白。', 1;

    IF NULLIF(LTRIM(RTRIM(@Title)), '') IS NULL
        THROW 50002, N'文件標題不可空白。', 1;

    BEGIN TRANSACTION;

    -- 產號查詢沿用 Day 9 已確認的 Transaction 與 Lock 設計。
    SELECT @NextValue =
        ISNULL(MAX(TRY_CONVERT(int, ItemNo)), 0) + 1
    FROM dbo.DocumentHeader WITH (UPDLOCK, HOLDLOCK)
    WHERE ProjectNo = @ProjectNo
      AND LEN(ItemNo) = 4
      AND ItemNo NOT LIKE '%[^0-9]%';

    SET @NextNo = RIGHT(
        '0000' + CONVERT(varchar(4), @NextValue), 4
    );

    INSERT INTO dbo.DocumentHeader (ProjectNo, ItemNo, Title)
    VALUES (@ProjectNo, @NextNo, @Title);

    COMMIT TRANSACTION;

    -- 只有資料庫成功建立主檔後,才回傳正式流水號。
    SELECT @NextNo AS NewItemNo;

END;

這不是可以單獨部署的完整 Script,而是用來標示 Ownership 的節錄。真正的重點是:同一個入口同時完成驗證、正式產號、主檔建立與結果回傳;Delphi 與 Trigger 都不再各算一次。

UI 與 Stored Procedure 都檢查必填,不一定代表重複設計:

  • UI 檢查是為了立即提示目前這位使用者;
  • Stored Procedure 檢查是為了讓任何呼叫者都滿足前置條件。

真正不應重複的是「誰來決定下一號」。正式號碼只能有一個權威來源,否則不同入口遲早會算出不同答案。

可以在多層防守同一個不變量,但不能讓多層各自決定同一個結果。


Trigger:看得見資料異動,卻看不見完整意圖

Trigger 最大的優點,是不論資料從 Delphi、Stored Procedure、匯入程式或人工 SQL 寫入,只要異動到資料表,它都有機會攔下來。

也正因如此,Trigger 很容易被當成「最後保險」,然後越寫越多:一開始只檢查格式,後來加入順序、權限、歷史資料、簽核狀態,最後變成藏在資料表背後的第二套應用程式。

問題是,Trigger 雖然知道哪些資料被 INSERT 或 UPDATE,卻不一定知道:

  • 這次異動來自正常新增、資料匯入,還是舊資料修復;
  • 使用者正在完成哪一個 Use Case;
  • 呼叫端是否準備同時新增其他關聯資料;
  • 這次變更是否屬於被允許的管理作業;
  • 錯誤訊息應對應哪一個畫面欄位。

換句話說,Trigger 擅長觀察「資料發生了什麼」,不擅長理解「使用者為什麼這樣做」。

因此,我不會讓 Trigger 負責產號,也不會把整套新增流程搬進 Trigger。它比較適合留下少量、明確且與資料表高度相關的防線,例如:

  • 阻擋已確認不允許的異動;
  • 核准後禁止任意修改特定欄位;
  • 對繞過標準入口的寫入留下稽核紀錄。

即使如此,每一條 Trigger 規則都必須回答:

  1. 它會不會擋到批次匯入、資料修復或未來的新入口?
  2. 相同限制是否能用 Constraint 或 Unique Index 更清楚地表達?

如果答案不確定,Trigger 就不應因為「比較保險」而先加上去。


Constraint/Unique Index:能宣告的底線,不要藏成程序邏輯

如果規則是「同一個案件內,流水號不得重複」,最直接的做法不是在三層各查一次,而是建立唯一索引:

CREATE UNIQUE INDEX UX_DocumentHeader_ProjectNo_ItemNo
ON dbo.DocumentHeader (ProjectNo, ItemNo);

不論資料從哪個入口寫入,只要違反:

同一 ProjectNo + ItemNo 不可重複

資料庫就會拒絕。

若所有資料都必須是四碼十進位,也可以考慮 CHECK Constraint。但本案例仍存在舊制三碼資料,不能貿然加入只允許四碼的全表限制,否則舊資料在更新其他欄位時也可能被擋住。

Unique Index 與 Constraint 的實作形式不同,但在這裡共同承擔的是 Schema 層的資料底線:能清楚宣告的永久資料不變量,不要只藏在 Delphi、Stored Procedure 或 Trigger 的 IF 裡。

Constraint 與 Unique Index 適合保護永久成立的資料不變量;若規則只適用於新流程,就不能假裝它對整張歷史資料表永遠成立。


一個危險的 Trigger:它自己再算一次預期號碼

假設 Stored Procedure 已新增 0648,Trigger 又在 AFTER INSERT 裡重新查最大值:

CREATE TRIGGER dbo.TR_DocumentHeader_CheckNo
ON dbo.DocumentHeader
AFTER INSERT
AS
BEGIN
    DECLARE @ExpectedNo int;

    SELECT @ExpectedNo = MAX(CONVERT(int, ItemNo)) + 1
    FROM dbo.DocumentHeader;

    IF EXISTS
    (
        SELECT 1
        FROM inserted
        WHERE CONVERT(int, ItemNo) <> @ExpectedNo
    )
    BEGIN
        ROLLBACK TRANSACTION;
        RETURN;
    END;
END;

這段看起來像完整防護,實際上有五個問題:

  1. AFTER INSERT 執行時,新資料已在目前交易中可見,MAX + 1 會比剛新增的號碼再大一號;
  2. 沒有限定案件範圍,不同案件可能互相影響;
  3. 假設 inserted 永遠只有一筆;
  4. 遇到舊制十六進位資料時,CONVERT(int, ItemNo) 可能失敗;
  5. Trigger 成為第二個產號決策者,與 Stored Procedure 重複規則。

AI 如果只看到「檢查新增號碼是否正確」,很可能快速產生這類 Trigger。它會查 inserted、會 Rollback,外觀很像正式答案,卻漏掉執行時間點、流水號作用域、舊資料格式與多筆異動語意。

AI 能比較各層的實作方式,但「哪一層有權決定這條規則」必須先由工程師定義。


程式碼實作

這次採用的責任分工

元件 負責 不負責
Delphi UI 必填提示、按鈕狀態、顯示錯誤、刷新結果 計算正式流水號
Stored Procedure 驗證建立條件、產號、主檔新增、Transaction、回傳結果 畫面互動
Trigger 阻擋已確認必須攔截的非標準異動 產號、猜測使用者意圖
Schema Constraint/Unique Index 以 NOT NULL 等 Schema 規則保護永久底線,並保證同一案件內號碼不可重複 決定下一號與顯示友善提示

資料流整理如下:

使用者按儲存
    ↓
Delphi 檢查畫面輸入
    ↓
呼叫 CreateDocument
    ↓
Stored Procedure 鎖定、產號並新增
    ↓
Schema Constraint/Unique Index 守住資料底線
    ↓
Trigger 執行必要的額外防線
    ↓
成功後回傳正式號碼
    ↓
Delphi 刷新並顯示結果

每一層都參與保護,但只有 Stored Procedure 決定正式號碼。


Delphi 不再自己產號,只負責送出與接收

procedure TDocumentForm.SaveNewDocument;
begin
  ValidateInput;

  btnSave.Enabled := False;
  try
    qryCreateDocument.Close;
    qryCreateDocument.ParamByName('ProjectNo').AsString :=
      Trim(edtProjectNo.Text);
    qryCreateDocument.ParamByName('Title').AsString :=
      Trim(edtTitle.Text);

    qryCreateDocument.Open;

    edtItemNo.Text :=
      qryCreateDocument.FieldByName('NewItemNo').AsString;

    RefreshDocument;
    ShowMessage('新增完成,流水號:' + edtItemNo.Text);
  finally
    btnSave.Enabled := True;
  end;
end;

這裡刻意沒有:

edtItemNo.Text := FormatFloat('0000', GetMaxItemNo + 1);

也沒有在重複鍵錯誤時自行加一再重試。資料庫若拒絕新增,Delphi 應顯示錯誤並保留必要輸入,而不是猜測資料庫現在會接受哪個號碼。


Trigger 只檢查 INSERT,不干涉一般 UPDATE

新規則只針對新增號碼。如果使用者之後只是修改標題或備註,Trigger 不應重新檢查「目前是否仍為下一號」。

否則歷史資料多年後修改備註時,可能因為號碼不是目前最大值而被拒絕。

CREATE OR ALTER TRIGGER dbo.TR_DocumentHeader_InsertGuard
ON dbo.DocumentHeader
AFTER INSERT
AS
BEGIN
    SET NOCOUNT ON;

    -- 這是目前已確認的介面限制。
    -- 若未來有合法批次匯入,必須重新設計。
    IF (SELECT COUNT(*) FROM inserted) <> 1
        THROW 51001, N'目前僅允許單筆新增。', 1;

    -- 不在 Trigger 重新計算下一號。
    IF EXISTS
    (
        SELECT 1
        FROM inserted
        WHERE ItemNo IS NULL
           OR LTRIM(RTRIM(ItemNo)) = ''
    )
        THROW 51002, N'流水號不可空白。', 1;
END;

這個範例刻意保持簡單。實際系統若所有寫入都已強制經過 Stored Procedure,而且 NOT NULL、其他 Constraint 與 Unique Index 已足以保護資料,這個 Trigger 甚至可能沒有保留的必要。

Trigger 不是架構成熟的證明;有時候,能安全刪掉不再需要的 Trigger,才代表責任真的收斂完成。


AI 說:三層都檢查最安全

如果把需求丟給 AI:

「請在 Delphi、Stored Procedure 與 Trigger 都檢查流水號,避免錯誤資料寫入。」

它很可能會忠實產生三份檢查。

這不是完全錯誤。對某些不變量,多層防守確實合理,例如:

  • UI 提前提示空白輸入;
  • Stored Procedure 拒絕 NULL、空字串與全空白字串;
  • Schema 至少以 NOT NULL 阻止 NULL;
  • 如果「不得為空白字串」也是永久資料不變量,再考慮加入適當的 CHECK Constraint。

錯的是沒有區分「重複驗證」與「重複決策」。若三層都各自計算下一號,就會變成三個權威來源。未來只要其中一層忘記同步,系統就會在不同入口給出不同結果。

工程師判決:部分接受

我接受多層防守,但加上兩個限制:

  1. 同一個不變量可以被多層驗證;
  2. 同一個業務結果只能有一個權威決策點。

在這次案例裡:

  • UI 提前提示空白輸入,Stored Procedure 拒絕 NULL、空字串與全空白字串;Schema 則至少以 NOT NULL 阻止 NULL,必要時再以 CHECK Constraint 保護「不得為空白字串」這項永久不變量;
  • 「同一案件不可重複」由 Unique Index 保證;
  • 「下一個正式號碼是多少」只由 Stored Procedure 決定;
  • Trigger 不自行產號,也不在 UPDATE 時套用新增規則。

這不是把所有責任都推給資料庫,而是讓每一層只做它最有能力證明正確的事。


修改前後的責任對照

問題 修改前 修改後
下一號由誰決定 Delphi、SQL、Trigger 都可能參與 Stored Procedure 唯一決定
同案件不可重複 先查再判斷 Unique Index 最終保證
必填欄位 只有畫面檢查 UI 提示+Stored Procedure 驗證+Schema 底線
Trigger 的角色 重算預期號碼、影響 INSERT/UPDATE 僅保留必要的新增防線
新增交易 Client 分段執行 Stored Procedure 內一次完成
錯誤處理 各層各自顯示或吞掉 資料庫回傳明確錯誤,Client 呈現

這張表比「最後用了哪一段 SQL」更重要,因為它記錄的是架構決策。

未來若新增 Web API,開發者不用重新猜規則,只要遵守同一個建立文件入口,並讓 Schema 層的 Constraint 與 Unique Index 繼續守住底線。


驗證清單:不是畫面成功就算完成

責任重新分層後,我至少會驗證以下情境。

正常流程

  • Delphi 輸入完整資料,可成功新增;
  • Stored Procedure 回傳實際已寫入的正式號碼;
  • 畫面刷新後與資料庫一致。

UI 防呆

  • 案件編號或標題空白時,在呼叫資料庫前提示;
  • 儲存期間按鈕停用;
  • 無論成功或失敗,按鈕最後都能恢復。

繞過 UI

  • 直接呼叫 Stored Procedure 並傳入空白參數,仍會被拒絕;
  • 直接 INSERT 重複的 ProjectNo + ItemNo,Unique Index 會拒絕;
  • 非標準入口若違反已明確定義的 Schema Constraint、Unique Index 或保留的 Trigger 規則,仍會被攔截。

歷史資料

  • 修改舊資料的備註或標題,不會因 Trigger 重跑新增規則而失敗;
  • 三碼舊制資料仍可讀取;
  • 舊資料是否允許修改流水號,依既有商業規則另外驗證。

多人操作

  • 兩個 Session 同時新增同一案件,不會得到相同號碼;
  • 不同案件同時新增時,不應被不必要地全部鎖住;
  • 失敗交易會 Rollback,不留下部分成功資料。

這些測試不是在確認每一層「都有跑到」,而是在驗證某一層被繞過時,下一層是否真的守得住。


Rollback:架構調整也要能退

這類修改同時碰到 Delphi、Stored Procedure、Trigger 與 Index,上線前不能只準備一份新 EXE。

我會保留:

  1. 舊版與新版 Client 的版本對應;
  2. Stored Procedure 修改前的 Script;
  3. Trigger 修改前的 Script;
  4. 建立 Unique Index 前的重複資料檢查結果;
  5. Unique Index 建立與移除的 Rollback Script/回復方式;
  6. 新舊 Client 是否會在一段時間內並存;
  7. 資料庫先上線時,舊 Client 會不會仍自行產號。

尤其是最後一點。如果資料庫已改成唯一產號來源,但現場還有舊版 Delphi 繼續自己計算,新舊版本並存期間就會同時存在兩種流程。

發布順序也是架構的一部分:

確認舊 Client 相容性
    ↓
部署資料庫防線與 Stored Procedure
    ↓
部署不再自行產號的新 Client
    ↓
確認版本使用狀況
    ↓
移除已無必要的舊邏輯

如果無法保證所有 Client 同時更新,就必須把混合版本納入測試,而不是假設 Production 永遠只有最新版。


不會 Delphi,也能帶走的三件事

  1. 前端驗證是使用者體驗,不是資料完整性的唯一防線。 任何能被另一個入口繞過的規則,都需要更靠近資料的保護。
  2. 多層防守不等於多個權威來源。 同一個不變量可以重複驗證,但同一個正式結果應只有一個決策點。
  3. Trigger 適合守資料底線,不適合偷偷成為第二套應用程式。 當它需要理解完整 Use Case 時,通常代表責任放錯地方。

今日小結

回頭看 Day 8 到 Day 10,這個需求原本只是把欄位從 CHAR(3) 改成 CHAR(4),最後卻必須依序釐清三件事:Day 8 先確認流水號過去代表的資料語意,Day 9 再處理多人同時取號時的 Concurrency,Day 10 最後回答規則確認後應由哪一層負責。

真正需要修改的從來不只是一個欄位長度,而是資料語意、Concurrency 與規則 Ownership。

因此,今天沒有再增加更複雜的產號演算法,而是處理一個更容易被忽略的問題:規則的所有權。

這次最後的分工是:

  • Delphi UI 負責早期提示與操作狀態;
  • Stored Procedure 負責完整新增 Use Case,並成為正式產號的唯一來源;
  • Schema 以 NOT NULL 等規則保護永久資料底線,Unique Index 保證同一案件內不可出現重複號碼;
  • Trigger 只保留確實需要、無法由 Constraint 或 Unique Index 清楚表達的額外資料庫防線;
  • UPDATE 不重新套用只屬於 INSERT 的產號規則。

AI 很擅長回答「這段驗證可以怎麼寫」,卻不會自動知道某條規則應由誰擁有。若只要求它在三層都補上檢查,它真的可能很勤勞地複製三份,然後留下三個未來需要同步維護的答案。

💡 今日金句:好的分層不是每一層都做一遍,而是每一層都知道自己為什麼有權做這件事。

下一篇,我們要面對資料格式改版最容易被低估的後座力:

新規則上線後,為什麼十年前的舊資料突然不能修改了?


上一篇
Day9 | 兩個人同時按新增,為什麼會拿到同一個號碼?從 Legacy Code 看 Race Condition
下一篇
Day 11|新規則上線後,為什麼十年前的舊資料突然不能修改了?
系列文
AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發 共 11 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言